Skip to content

Preserve OCaml namespaces in generated module fields - #1847

Open
TheCBaH wants to merge 7 commits into
melange-re:mainfrom
TheCBaH:devel.namespace
Open

Preserve OCaml namespaces in generated module fields#1847
TheCBaH wants to merge 7 commits into
melange-re:mainfrom
TheCBaH:devel.namespace

Conversation

@TheCBaH

@TheCBaH TheCBaH commented Aug 2, 2026

Copy link
Copy Markdown
Contributor

Summary

OCaml allows structure components from different namespaces to share a name. For example, an exception constructor and a module can both be named Foo. JavaScript objects have a single property namespace, however, and Melange previously discarded the OCaml namespace while lowering module fields. As a result, the valid OCaml in the reproduction below failed to compile with Foo are exported as twice.

This PR preserves each runtime field's OCaml namespace throughout compiler-libs and the Melange backend. The namespace is then used to give every field an unambiguous JavaScript name:

OCaml component OCaml name JavaScript name
Value foo foo
Module Foo Foo
Extension constructor Foo Foo$extension
Class foo foo$class

Values and modules keep their existing names. Extension constructors and classes are also exported under their original names when those names are unambiguous, preserving compatibility for existing JavaScript consumers.

Reproduction

exception Foo of string

module Foo = struct end

Previously, compiling this file failed:

$ melc names.ml
File "names.ml", line 1:
Error: Foo are exported as twice

With this change, both fields are emitted:

$ opam exec -- dune exec -- bin/melc.exe "$(readlink -f names.ml)"
// Generated by Melange
'use strict';

const Caml_exceptions = require("melange.js/caml_exceptions.js");

const Foo = /* @__PURE__ */ Caml_exceptions.create("Names.Foo");

const Foo$1 = {};

module.exports = {
  Foo$extension: Foo,
  Foo: Foo$1,
}
/* No side effect */

OCaml references to the exception use Foo$extension, while references to the module continue to use Foo.

Implementation

  • Add a runtime-field descriptor that pairs an identifier with its Shape.Sig_component_kind.t.
  • Preserve this descriptor through structure coercions, Lambda module blocks and field accesses, cross-module metadata, and the JavaScript IR.
  • Use one namespace-aware naming policy for top-level exports, nested module objects, and imported field accesses.
  • Emit the same canonical names in CommonJS and ES module output.
  • Keep the original, unmangled name as a compatibility alias when it does not conflict with another field and can be emitted safely.

Tests

The regression test covers:

  • exception/module and class/value collisions;
  • top-level, nested, local, first-class, and functor-produced modules;
  • cross-compilation-unit access;
  • signature coercions, include, and open;
  • CommonJS and ES module output;
  • cross-module optimization; and
  • compatibility aliases for non-colliding fields.

TheCBaH and others added 7 commits August 2, 2026 15:24
Translmod now hands out Runtime_fields.t for a unit's exports, so
Lam_stats, Lam_coercion and J.program carry the namespace along with the
identifier, and the CommonJS/ESM printers and the .cmj export table ask
Runtime_fields for the name a field goes by at runtime rather than
assuming it is the OCaml one. The duplicate-export guard compares those
runtime names, which is what actually has to be unique in a JavaScript
object.

Mechanical: Runtime_fields.name is still the OCaml name, so the
generated code is unchanged.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Extension constructors and classes now reach the printers with a
runtime name of their own (Foo$extension, foo$class), which is what
lets a structure export both an exception Foo and a module Foo instead
of failing with "Foo are exported as twice".

Mangled fields are additionally exposed under their plain OCaml name so
that JavaScript callers keep working: in the unit's exports, and in
nested module objects when the value is a variable -- a coerced field
can be an arbitrary expression and an object literal has nowhere to
bind it. Skipped when another field already answers to that name.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…e.js

The node invocations resolved the runtime through the repository's
checked-in node_modules, which the nix sandbox does not have; declaring
the library makes dune materialize it under _build.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@anmonteiro

Copy link
Copy Markdown
Member

I think I understand what this does, but it's unclear to me why we want it?

Is there a failing test case that shows e.g. a name clash or similar? I'm curious what's the problem you're trying to solve with this.

@TheCBaH

TheCBaH commented Aug 4, 2026

Copy link
Copy Markdown
Contributor Author

Is there a failing test case that shows e.g. a name clash or similar?

OCaml code in the wild, for example: https://github.com/dbuenzli/jsont/blob/main/src/jsont.ml#L24

anmonteiro added a commit that referenced this pull request Aug 4, 2026
@anmonteiro

Copy link
Copy Markdown
Member

oh thank you!

I wasn't even aware of that -- I reproduced this in #1851, and I'll take a look at this and check whether this is the fix we'll want to go with.

Nevertheless, appreciate the report, but it's unclear to me yet that we'll want to go with this exact approach (it seems to make some of the names way worse, unfortunately).

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants